/* OpenSAM Single Sign On Module
*
* 
*    This is a BSD style permissive license.
*    This module is original work by the author.
*
* Copyright (c) 2007, iNetOffice, Inc.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*     * Redistributions of source code must retain the above copyright
*       notice, this list of conditions and the following disclaimer.
*     * Redistributions in binary form must reproduce the above copyright
*       notice, this list of conditions and the following disclaimer in the
*       documentation and/or other materials provided with the distribution.
*     * Neither the name of iNetOffice nor the
*       names of its contributors may be used to endorse or promote products
*       derived from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY iNetOffice ``AS IS'' AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL iNetOffice BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*
* Author: Tom Snyder, iNetOffice, Inc.
*/

/* -- OpenSAM_SSO.java --
 * -- Version 1.00.a  - updated 9/13/07. --
 *
 * SEE: http://opensam.org/4%20SSO%20and%20Provisioning.html
 *
 * This module contains a utility routine that implements OpenSAM Single Sign On.
 *
 * Customize and integrate this module with your authentication routines.
 */
 
/* ####################################################################
 *  TO USE:
 * 
 *  --- 1. Install and Compile ---
 * Install and compile this module into the classes dir of your webapp.
 * For example:
 *  /srv/www/tomcat5/base/webapps/ROOT/WEB-INF/classes/org/opensam/sso/OpenSAM_SSO.java
 *  javac OpenSAM_SSO.java
 * 
 *  --- 2. Import into your "Unauthenticated Entry Point" JSP or servlet file:
 * <%@ page import = "org.opensam.sso.*" %>
 *
 *  --- 3. Call the authenticate() method ----
      OpenSAM_SSO sso = new OpenSAM_SSO();
      out.println("...calling .authenticate()");

      int auth_result = OpenSAM_SSO.resultERROR; // simple value meaning "none yet".
      try {
        auth_result = sso.authenticate( request.getParameterMap() );
      } catch( Exception exc ) {
        out.println("Exception: " + exc);
      }
 * 
 *  --- 4. Check the result ---
 *   if( auth_result == OpenSAM_SSO.resultNOOP ) {
 *      // No OpenSAM CGI params were found and no attempt at login was made.
 *      // Proceed with your normal no-login session handling.
 *   }
     else if( auth_result == OpenSAM_SSO.resultSUCCESS ) {
 *      // CHECK the OUT_StorageDomainAndPath to ensure it is valid for the
 *      //           OUT_StorageUserName account.
 *      // Establish your logged in session using OUT_StorageUserName.
 *      // Save all the OUT_ class values to your session data for future use to make
 *      // requests to the WebDAV server.
 *   }
 *   else {
 *      // Login attempt was made but failed. Print some diagnostic info for the engineer.
 *      out.println("...failed, result code: "+auth_result+", Err msg: "+sso.get_errorMessage() );
 *   }
 * 
 *   The possible results are:
 *     1 OpenSAM_SSO.resultNOOP
 *     2 OpenSAM_SSO.resultMISSING_PARAMS
 *     3 OpenSAM_SSO.resultINVALID_USERID_PASSWORD
 *     4 OpenSAM_SSO.resultFAILED_CHECK_STATUS_CODE
 *     5 OpenSAM_SSO.resultERROR
 *     6 OpenSAM_SSO.resultSUCCESS
 * 
 *   Other errors, such as a malformed StorageServerUrl, will throw the typical exceptions.
 */

package org.opensam.sso;

import java.net.*;
import java.io.*;
import java.util.*;
import java.util.regex.Pattern;
import java.util.regex.Matcher;

import org.apache.commons.httpclient.*;
import org.apache.commons.httpclient.methods.*;
import org.apache.commons.httpclient.auth.AuthScope;


public class OpenSAM_SSO {
  
  // RESULT CODES -----------------------------------------------------
  //
  // The authenticate() method can return these normal result codes:
  //

  // No login was attempted because no OpenSAM CGI params were found.
  // Simply proceed normally.
  public static final int resultNOOP = 1;   

  // Some, but not enough OpenSAM CGI params were found. Report the 
  // get_errorMessage() to the user so they can get it fixed.
  public static final int resultMISSING_PARAMS = 2;

  // A login attempt was made but rejected by the StorageServerUrl server via status 401
  // Tell the user the login failed and offer them a chance to re-enter their username/password.
  // Once entered, resubmit to the authenticate() routine with the RequestCGIParameters MAP
  // holding the StorageUserName and StoragePassword filled in with the user's values. 
  //  BE SURE THIS MAP THAT IS RE-SUBMITTED DOES NOT CONTAIN ANY ORIGINAL CGI PARAMS (just delete
  //  the CGI one and create a new one from scratch).
  public static final int resultINVALID_USERID_PASSWORD = 3;

  // Authentication failed via some HTTP status other than the usual 401. May be a 
  // server problem. Report to user so they can relay to their support folks.
  public static final int resultFAILED_CHECK_STATUS_CODE = 4;

  // Some error occurred, probably an exception was thrown, catch the exception.
  public static final int resultERROR = 5;

  // The login attempt to the StorageServerUrl server succeeded.
  // This is the only value that indicates a successful login.
  public static final int resultSUCCESS = 6;

  // Used internally here. Should never be returned to a caller.
  private static final int resultPERFORM_THIS_MODE = 7;
  //
  // END RESULT CODES -----------------------------------------------------


  // PRIVATE MEMBERS:
  // These to-login-or-not-to-login values are managed closely by this class:
  private int     lastAuthenticateResult;
  private int     httpStatusCode;       // HTTP status code related to last authenticate() attempt.
  private String  errorMessage;

  // Handy get-functions to retrieve various tidbits of info:
  //            Returns the same code as the last .authenticate() call did.
  public int    get_lastAuthenticationResult() { return( this.lastAuthenticateResult ); }
  //            Returns the HTTP status code of the last request - 200, 207, 301, 404, etc.
  public int    get_httpStatusCode()           { return( this.httpStatusCode ); }
  //            Returns an error message string that elaborates on any error. Not always set.
  public String get_errorMessage()             { return( this.errorMessage ); }
  
  
  // OUT MEMBERS:
  // These members are commonly queried by the call after an authenticate() and might
  // be updated by the client for their own bookkeeping.
  public String OUT_StorageServerUrl;
  public String OUT_StorageUserName;
  public String OUT_StorageSessionId;
  public String OUT_StoragePassword;
  public String OUT_determinedWebDAVUrlParameters;
  public String OUT_determinedStorageDomainAndPath;


  // Create safe defaults:
  public OpenSAM_SSO()
  {
    this.lastAuthenticateResult = resultNOOP;
    this.httpStatusCode = 0;
    this.errorMessage = "";  // default value.
    
    this.OUT_StorageServerUrl = "";  // default value.
    this.OUT_StorageUserName = "";  // default value.
    this.OUT_StorageSessionId = "";  // default value.
    this.OUT_StoragePassword = "";  // default value.
    this.OUT_determinedWebDAVUrlParameters = "";  // default value.
    this.OUT_determinedStorageDomainAndPath = "";  // default value.
  }

  // ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
  //
  // MAIN ENTRY POINT:
  //
  // ++++++++++++++++++++++++++++++++++++++++++++++++++++++++

  public int authenticate( Map RequestCGIParameters ) throws Exception
  {
      // 
      // FIRST
      //
      // Analyze CGI parameters and determine what Single Sign On type we are
      // attempting, if any.
      //
      boolean hasStorageServerUrl = RequestCGIParameters.containsKey("StorageServerUrl");

      // Salesforce SSO?
      this.lastAuthenticateResult = this.checkforSalesforceSSO( RequestCGIParameters );
      if( this.lastAuthenticateResult == this.resultMISSING_PARAMS ) return( this.resultMISSING_PARAMS );
      if( this.lastAuthenticateResult != this.resultPERFORM_THIS_MODE )
      {
        // Now try StorageSessionId based SSO:
        this.lastAuthenticateResult = this.checkforSessionIdSSO(RequestCGIParameters);
      }
      if( this.lastAuthenticateResult == this.resultMISSING_PARAMS ) return( this.resultMISSING_PARAMS );
      if( this.lastAuthenticateResult != this.resultPERFORM_THIS_MODE )
      {
        // Now try UserName/Password based SSO:
        this.lastAuthenticateResult = this.checkforUserNamePasswordSSO(RequestCGIParameters);
      }
      if( this.lastAuthenticateResult != this.resultPERFORM_THIS_MODE ) {
        // No attempt to be made. Our final result is the result of that last check.
        if( hasStorageServerUrl && this.lastAuthenticateResult == this.resultNOOP ) {
          this.lastAuthenticateResult = this.resultMISSING_PARAMS;
        }
        return( this.lastAuthenticateResult );
      }

      // All SSO types require StorageServerUrl:
      if( !hasStorageServerUrl )
      {
        this.errorMessage = "Missing CGI param StorageServerUrl";
        return (this.lastAuthenticateResult = this.resultMISSING_PARAMS);
      }

      // FIll in a safe, default result until we find out otherwise. This is the result
      // that will remain in our obj if we throw an exception.
      this.lastAuthenticateResult = this.resultERROR;


      // We've identified an SSO type and it has all the required CGI parameter values.
      // Our OUT_determinedWebDAVUrlParameters contains the extra pass-to-webdav CGI params we need.

      //
      // SECOND
      //
      // Misconfigured Server Pre-test
      // If the server allows an unauthenticated request, then it is not acting as an
      // HTTP authenticating server and cannot be trusted.
      //
      String StorageServerUrl_touse = this.get_the_one_cgi_param(RequestCGIParameters,"StorageServerUrl");
      try {
        HttpClient client = new HttpClient();
        System.out.println("m_StorServ: " + StorageServerUrl_touse);
        GetMethod method = new GetMethod(StorageServerUrl_touse);
        method.setFollowRedirects(true);

        // Execute the GET method
        this.httpStatusCode = client.executeMethod(method);
        if (this.httpStatusCode != 401)
        {
          throw new Exception("LOGIN FAILED: Misconfigured server pre-test failed, server returned status " + this.httpStatusCode
            + " when no credentials were provided");
        }
      } catch( Exception exc ) {
        throw new Exception( "LOGIN FAILED: Misconfigured server pre-test threw exception: "+exc+
          " (ServerUrl="+StorageServerUrl_touse+")" );
      }

      //
      // Third
      //
      // Prepare for actual authenticated request:

      // FIll in the webdav CGI params. They may be "".
      String StorageServerUrl_withOptionalParams = StorageServerUrl_touse + this.OUT_determinedWebDAVUrlParameters;

      String StorageUserName_touse = this.get_the_one_cgi_param(RequestCGIParameters,"StorageUserName");
      String StoragePassword_touse = "";
      try {
        HttpClient client = new HttpClient();

        // If we are doing normal Username/Password, then plug those values in:
        if (RequestCGIParameters.containsKey("StoragePassword"))
        {
          StoragePassword_touse = this.get_the_one_cgi_param(RequestCGIParameters,"StoragePassword");
          Credentials logincreds = new UsernamePasswordCredentials(StorageUserName_touse, StoragePassword_touse);
          client.getState().setCredentials(AuthScope.ANY, logincreds);
        }

        GetMethod method = new GetMethod(StorageServerUrl_withOptionalParams);
        method.setFollowRedirects(true);

        // Execute the GET method
        this.httpStatusCode = client.executeMethod(method);
      } catch( Exception exc ) {
        throw new Exception( "LOGIN FAILED: Authenticated remote request threw exception: "+exc+
          " (ServerUrl="+StorageServerUrl_withOptionalParams+")" );
      }

      //
      // FOURTH
      //
      // Analyze the result:
      if (this.httpStatusCode == 401)
      {
        // typical bad username/password indicator:
        return (this.lastAuthenticateResult = resultINVALID_USERID_PASSWORD);
      }
      if (this.httpStatusCode >= 200 && this.httpStatusCode <= 299)
      {
        // SUCCESSFULL LOGIN
        //  Return the good values in the out params:
        this.OUT_StorageServerUrl = StorageServerUrl_touse;
        this.OUT_StorageUserName  = StorageUserName_touse;
        this.OUT_StorageSessionId = this.get_the_one_cgi_param(RequestCGIParameters,"StorageSessionId");
        this.OUT_StoragePassword  = StoragePassword_touse;
        this.OUT_determinedStorageDomainAndPath = this.determineStorageDomainAndPath( StorageServerUrl_touse );

        return (this.lastAuthenticateResult = this.resultSUCCESS);
      }
      return (this.lastAuthenticateResult = this.resultFAILED_CHECK_STATUS_CODE);
  }

  private String get_the_one_cgi_param(Map RequestCGIParameters, String key)
  {
    String[] valueArray = (String[])RequestCGIParameters.get(key);
    if (valueArray == null) return (null);
    if (valueArray.length < 1) return (null);
    return (valueArray[0].trim());
  }

  // check the CGI params for hints of SalesforceSSO:
  // See table 4.2 at http://opensam.org/4%20SSO%20and%20Provisioning.html.
  private int checkforSalesforceSSO(Map RequestCGIParameters) throws UnsupportedEncodingException
  {
    // Build a bit mask of what SalesForce SSO params are available.
    int bitmask = 0;
    if( RequestCGIParameters.containsKey("StorageSForceUrl") )       bitmask |= 1;
    if( RequestCGIParameters.containsKey("StorageSForceSessionId") ) bitmask |= 2;
    if( RequestCGIParameters.containsKey("StorageSForceOrg") )       bitmask |= 4;
    if( bitmask == 0 ) return( this.resultNOOP ); // No sforce params at all.
    if( bitmask != 7 ) {
      // We're missing an SForce param:
      this.errorMessage = "";
      if( (bitmask & 1) == 0 ) this.errorMessage += "Missing CGI param StorageSForceUrl ";
      if( (bitmask & 2) == 0 ) this.errorMessage += "Missing CGI param StorageSForceSessionId ";
      if( (bitmask & 4) == 0 ) this.errorMessage += "Missing CGI param StorageSForceOrg ";
      return( this.resultMISSING_PARAMS );
    }

    // We have all we need. Now fasion the WebDAV URL CGI params for subsequent requests:
      // .trimming the CGI param values is convenient since constructing the Launch Link can sometimes result in
      // misc spaces within the OpenSAM CGI params. Esp. with SalesForce.com's 'custom' link tools.
    this.OUT_determinedWebDAVUrlParameters = 
      "?StorageSForceUrl="       + URLEncoder.encode(this.get_the_one_cgi_param(RequestCGIParameters,"StorageSForceUrl"),"UTF-8") +
      "&StorageSForceSessionId=" + URLEncoder.encode(this.get_the_one_cgi_param(RequestCGIParameters, "StorageSForceSessionId"), "UTF-8") +
      "&StorageSForceOrg=" + URLEncoder.encode(this.get_the_one_cgi_param(RequestCGIParameters, "StorageSForceOrg"), "UTF-8");

    return( this.resultPERFORM_THIS_MODE );
  }

  private int checkforSessionIdSSO(Map RequestCGIParameters) throws UnsupportedEncodingException
  {
    if (!RequestCGIParameters.containsKey("StorageSessionId"))
    {
      return (this.resultNOOP); // no SessionId means don't try this mode at all.
    }
    if (!RequestCGIParameters.containsKey("StorageUserName"))
    {
      // SessionId mode requires the UserName.
      this.errorMessage = "Missing CGI param StorageUserName";
      return (this.resultMISSING_PARAMS);
    }

    // We have all we need. Now fasion the WebDAV URL CGI params for subsequent requests:
    this.OUT_determinedWebDAVUrlParameters =
      "?StorageUserName=" + URLEncoder.encode(this.get_the_one_cgi_param(RequestCGIParameters, "StorageUserName"), "UTF-8") +
      "&StorageSessionId=" + URLEncoder.encode(this.get_the_one_cgi_param(RequestCGIParameters, "StorageSessionId"), "UTF-8");

    return (this.resultPERFORM_THIS_MODE);
  }

  private int checkforUserNamePasswordSSO(Map RequestCGIParameters)
  {
    // Build a bit mask of what SalesForce SSO params are available.
    int bitmask = 0;
    if (RequestCGIParameters.containsKey("StorageUserName")) bitmask |= 1;
    if (RequestCGIParameters.containsKey("StoragePassword")) bitmask |= 2;
    if (bitmask == 0) return (this.resultNOOP); // No sforce params at all.
    if (bitmask != 3)
    {
      // We're missing an SForce param:
      if ( (bitmask & 1) == 0 ) this.errorMessage = "Missing CGI param StorageUserName";
      else this.errorMessage = "Missing CGI param StoragePassword";
      return (this.resultMISSING_PARAMS);
    }

    return (this.resultPERFORM_THIS_MODE);
  }

  // The caller must confirm that the used WebDAV server is correct for the account.
  // This helper extracts out the domain and path portions which are significant.
  // RETURNS: string containing the domain name, tld, and path portions of the URL.
  //  FAILURE: returns null.
  private String determineStorageDomainAndPath( String StorageServerUrl_touse ) {
    Pattern pattern_stripprotocol = Pattern.compile("^([^:]+:)?(//)?([^?]*)");
    Matcher matcher_stripprotocol = pattern_stripprotocol.matcher(StorageServerUrl_touse);
    
    if( matcher_stripprotocol.find() ) {
      // Note that this regex is anchored at the end of the string $ so it will
      // skip any number of this.that hostname prefixes on the domain name.
      Pattern pattern_domandpath = Pattern.compile("([^./]+)\\.([^./]+)(/.*)?$");
      Matcher matcher_domandpath = pattern_domandpath.matcher(matcher_stripprotocol.group(3));
      if( matcher_domandpath.find() ) {
        String strDomain = (matcher_domandpath.group(1)!=null ? matcher_domandpath.group(1) : "");
        String strTld    = (matcher_domandpath.group(2)!=null ? matcher_domandpath.group(2) : "");
        String strPath   = (matcher_domandpath.group(3)!=null ? matcher_domandpath.group(3) : "");
        return( strDomain + "." + strTld + strPath );
      }
    }

    return (null);
  }
};
